diff options
Diffstat (limited to 'src/app/(main)/websites/[websiteId]/sessions')
12 files changed, 622 insertions, 0 deletions
diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionActivity.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionActivity.tsx new file mode 100644 index 0000000..cbb2810 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/sessions/SessionActivity.tsx @@ -0,0 +1,94 @@ +import { + Button, + Column, + Dialog, + DialogTrigger, + Heading, + Icon, + Popover, + Row, + StatusLight, + Text, +} from '@umami/react-zen'; +import { isSameDay } from 'date-fns'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { useMessages, useMobile, useSessionActivityQuery, useTimezone } from '@/components/hooks'; +import { Eye, FileText } from '@/components/icons'; +import { EventData } from '@/components/metrics/EventData'; +import { Lightning } from '@/components/svg'; + +export function SessionActivity({ + websiteId, + sessionId, + startDate, + endDate, +}: { + websiteId: string; + sessionId: string; + startDate: Date; + endDate: Date; +}) { + const { formatMessage, labels } = useMessages(); + const { formatTimezoneDate } = useTimezone(); + const { data, isLoading, error } = useSessionActivityQuery( + websiteId, + sessionId, + startDate, + endDate, + ); + const { isMobile } = useMobile(); + let lastDay = null; + + return ( + <LoadingPanel data={data} isLoading={isLoading} error={error}> + <Column gap> + {data?.map(({ eventId, createdAt, urlPath, eventName, visitId, hasData }) => { + const showHeader = !lastDay || !isSameDay(new Date(lastDay), new Date(createdAt)); + lastDay = createdAt; + + return ( + <Column key={eventId} gap> + {showHeader && <Heading size="1">{formatTimezoneDate(createdAt, 'PPPP')}</Heading>} + <Row alignItems="center" gap="6" height="40px"> + <StatusLight color={`#${visitId?.substring(0, 6)}`}> + <Text wrap="nowrap">{formatTimezoneDate(createdAt, 'pp')}</Text> + </StatusLight> + <Row alignItems="center" gap="2"> + <Icon>{eventName ? <Lightning /> : <Eye />}</Icon> + <Text wrap="nowrap"> + {eventName + ? formatMessage(labels.triggeredEvent) + : formatMessage(labels.viewedPage)} + </Text> + <Text weight="bold" style={{ maxWidth: isMobile ? '400px' : null }} truncate> + {eventName || urlPath} + </Text> + {hasData > 0 && <PropertiesButton websiteId={websiteId} eventId={eventId} />} + </Row> + </Row> + </Column> + ); + })} + </Column> + </LoadingPanel> + ); +} + +const PropertiesButton = props => { + return ( + <DialogTrigger> + <Button variant="quiet"> + <Row alignItems="center" gap> + <Icon> + <FileText /> + </Icon> + </Row> + </Button> + <Popover placement="right"> + <Dialog> + <EventData {...props} /> + </Dialog> + </Popover> + </DialogTrigger> + ); +}; diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionData.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionData.tsx new file mode 100644 index 0000000..7c82c17 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/sessions/SessionData.tsx @@ -0,0 +1,32 @@ +import { Box, Column, Label, Row, Text } from '@umami/react-zen'; +import { Empty } from '@/components/common/Empty'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { useSessionDataQuery } from '@/components/hooks'; +import { DATA_TYPES } from '@/lib/constants'; + +export function SessionData({ websiteId, sessionId }: { websiteId: string; sessionId: string }) { + const { data, isLoading, error } = useSessionDataQuery(websiteId, sessionId); + + return ( + <LoadingPanel data={data} isLoading={isLoading} error={error}> + {!data?.length && <Empty />} + <Column gap="6"> + {data?.map(({ dataKey, dataType, stringValue }) => { + return ( + <Column key={dataKey}> + <Label>{dataKey}</Label> + <Row alignItems="center" gap> + <Text>{stringValue}</Text> + <Box paddingY="1" paddingX="2" border borderRadius borderColor="5"> + <Text color="muted" size="1"> + {DATA_TYPES[dataType]} + </Text> + </Box> + </Row> + </Column> + ); + })} + </Column> + </LoadingPanel> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionInfo.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionInfo.tsx new file mode 100644 index 0000000..f15e6ee --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/sessions/SessionInfo.tsx @@ -0,0 +1,85 @@ +import { Column, Grid, Icon, Label, Row } from '@umami/react-zen'; +import type { ReactNode } from 'react'; +import { DateDistance } from '@/components/common/DateDistance'; +import { TypeIcon } from '@/components/common/TypeIcon'; +import { useFormat, useLocale, useMessages, useRegionNames } from '@/components/hooks'; +import { Calendar, KeyRound, Landmark, MapPin } from '@/components/icons'; + +export function SessionInfo({ data }) { + const { locale } = useLocale(); + const { formatMessage, labels } = useMessages(); + const { formatValue } = useFormat(); + const { getRegionName } = useRegionNames(locale); + + return ( + <Grid columns="repeat(auto-fit, minmax(200px, 1fr)" gap> + <Info label={formatMessage(labels.distinctId)} icon={<KeyRound />}> + {data?.distinctId} + </Info> + + <Info label={formatMessage(labels.lastSeen)} icon={<Calendar />}> + <DateDistance date={new Date(data.lastAt)} /> + </Info> + + <Info label={formatMessage(labels.firstSeen)} icon={<Calendar />}> + <DateDistance date={new Date(data.firstAt)} /> + </Info> + + <Info + label={formatMessage(labels.country)} + icon={<TypeIcon type="country" value={data?.country} />} + > + {formatValue(data?.country, 'country')} + </Info> + + <Info label={formatMessage(labels.region)} icon={<MapPin />}> + {getRegionName(data?.region)} + </Info> + + <Info label={formatMessage(labels.city)} icon={<Landmark />}> + {data?.city} + </Info> + + <Info + label={formatMessage(labels.browser)} + icon={<TypeIcon type="browser" value={data?.browser} />} + > + {formatValue(data?.browser, 'browser')} + </Info> + + <Info + label={formatMessage(labels.os)} + icon={<TypeIcon type="os" value={data?.os?.toLowerCase()?.replaceAll(/\W/g, '-')} />} + > + {formatValue(data?.os, 'os')} + </Info> + + <Info + label={formatMessage(labels.device)} + icon={<TypeIcon type="device" value={data?.device} />} + > + {formatValue(data?.device, 'device')} + </Info> + </Grid> + ); +} + +const Info = ({ + label, + icon, + children, +}: { + label: string; + icon?: ReactNode; + children: ReactNode; +}) => { + return ( + <Column> + <Label>{label}</Label> + <Row alignItems="center" gap> + {icon && <Icon>{icon}</Icon>} + {children || '—'} + </Row> + </Column> + ); +}; diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionModal.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionModal.tsx new file mode 100644 index 0000000..d658038 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/sessions/SessionModal.tsx @@ -0,0 +1,41 @@ +import { Column, Dialog, Modal, type ModalProps } from '@umami/react-zen'; +import { SessionProfile } from '@/app/(main)/websites/[websiteId]/sessions/SessionProfile'; +import { useNavigation } from '@/components/hooks'; + +export interface SessionModalProps extends ModalProps { + websiteId: string; +} + +export function SessionModal({ websiteId, ...props }: SessionModalProps) { + const { + router, + query: { session }, + updateParams, + } = useNavigation(); + const handleOpenChange = (isOpen: boolean) => { + if (!isOpen) { + router.push(updateParams({ session: undefined })); + } + }; + + return ( + <Modal + placement="bottom" + offset="80px" + isOpen={!!session} + onOpenChange={handleOpenChange} + isDismissable + {...props} + > + <Column height="100%" maxWidth="1320px" style={{ margin: '0 auto' }}> + <Dialog variant="sheet"> + {({ close }) => ( + <Column padding="6"> + <SessionProfile websiteId={websiteId} sessionId={session} onClose={() => close()} /> + </Column> + )} + </Dialog> + </Column> + </Modal> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionProfile.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionProfile.tsx new file mode 100644 index 0000000..6624d43 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/sessions/SessionProfile.tsx @@ -0,0 +1,84 @@ +import { + Button, + Column, + Icon, + Row, + Tab, + TabList, + TabPanel, + Tabs, + TextField, +} from '@umami/react-zen'; +import { X } from 'lucide-react'; +import { Avatar } from '@/components/common/Avatar'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { useMessages, useWebsiteSessionQuery } from '@/components/hooks'; +import { SessionActivity } from './SessionActivity'; +import { SessionData } from './SessionData'; +import { SessionInfo } from './SessionInfo'; +import { SessionStats } from './SessionStats'; + +export function SessionProfile({ + websiteId, + sessionId, + onClose, +}: { + websiteId: string; + sessionId: string; + onClose?: () => void; +}) { + const { data, isLoading, error } = useWebsiteSessionQuery(websiteId, sessionId); + const { formatMessage, labels } = useMessages(); + + return ( + <LoadingPanel + data={data} + isLoading={isLoading} + error={error} + loadingIcon="spinner" + loadingPlacement="absolute" + > + {data && ( + <Column gap> + {onClose && ( + <Row justifyContent="flex-end"> + <Button onPress={onClose} variant="quiet"> + <Icon> + <X /> + </Icon> + </Button> + </Row> + )} + <Column gap="6"> + <Row justifyContent="center" alignItems="center" gap="6"> + <Avatar seed={data?.id} size={128} /> + <Column width="360px"> + <TextField label="ID" value={data?.id} allowCopy /> + </Column> + </Row> + <SessionStats data={data} /> + <SessionInfo data={data} /> + + <Tabs> + <TabList> + <Tab id="activity">{formatMessage(labels.activity)}</Tab> + <Tab id="properties">{formatMessage(labels.properties)}</Tab> + </TabList> + <TabPanel id="activity"> + <SessionActivity + websiteId={websiteId} + sessionId={sessionId} + startDate={data?.firstAt} + endDate={data?.lastAt} + /> + </TabPanel> + <TabPanel id="properties"> + <SessionData sessionId={sessionId} websiteId={websiteId} /> + </TabPanel> + </Tabs> + </Column> + </Column> + )} + </LoadingPanel> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionProperties.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionProperties.tsx new file mode 100644 index 0000000..1693d05 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/sessions/SessionProperties.tsx @@ -0,0 +1,97 @@ +import { Column, Grid, ListItem, Select } from '@umami/react-zen'; +import { useMemo, useState } from 'react'; +import { PieChart } from '@/components/charts/PieChart'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { + useMessages, + useSessionDataPropertiesQuery, + useSessionDataValuesQuery, +} from '@/components/hooks'; +import { ListTable } from '@/components/metrics/ListTable'; +import { CHART_COLORS } from '@/lib/constants'; + +export function SessionProperties({ websiteId }: { websiteId: string }) { + const [propertyName, setPropertyName] = useState(''); + const { formatMessage, labels } = useMessages(); + const { data, isLoading, isFetching, error } = useSessionDataPropertiesQuery(websiteId); + + const properties: string[] = data?.map(e => e.propertyName); + + return ( + <LoadingPanel + isLoading={isLoading} + isFetching={isFetching} + data={data} + error={error} + minHeight="300px" + > + <Column gap="6"> + {data && ( + <Grid columns="repeat(auto-fill, minmax(300px, 1fr))" gap> + <Select + label={formatMessage(labels.event)} + value={propertyName} + onChange={setPropertyName} + placeholder="" + > + {properties?.map(p => ( + <ListItem key={p} id={p}> + {p} + </ListItem> + ))} + </Select> + </Grid> + )} + {propertyName && <SessionValues websiteId={websiteId} propertyName={propertyName} />} + </Column> + </LoadingPanel> + ); +} + +const SessionValues = ({ websiteId, propertyName }) => { + const { data, isLoading, isFetching, error } = useSessionDataValuesQuery(websiteId, propertyName); + + const propertySum = useMemo(() => { + return data?.reduce((sum, { total }) => sum + total, 0) ?? 0; + }, [data]); + + const chartData = useMemo(() => { + if (!propertyName || !data) return null; + return { + labels: data.map(({ value }) => value), + datasets: [ + { + data: data.map(({ total }) => total), + backgroundColor: CHART_COLORS, + borderWidth: 0, + }, + ], + }; + }, [propertyName, data]); + + const tableData = useMemo(() => { + if (!propertyName || !data || propertySum === 0) return []; + return data.map(({ value, total }) => ({ + label: value, + count: total, + percent: 100 * (total / propertySum), + })); + }, [propertyName, data, propertySum]); + + return ( + <LoadingPanel + isLoading={isLoading} + isFetching={isFetching} + data={data} + error={error} + minHeight="300px" + > + {data && ( + <Grid columns="1fr 1fr" gap> + <ListTable title={propertyName} data={tableData} /> + <PieChart type="doughnut" chartData={chartData} /> + </Grid> + )} + </LoadingPanel> + ); +}; diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionStats.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionStats.tsx new file mode 100644 index 0000000..e25be9a --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/sessions/SessionStats.tsx @@ -0,0 +1,21 @@ +import { useMessages } from '@/components/hooks'; +import { MetricCard } from '@/components/metrics/MetricCard'; +import { MetricsBar } from '@/components/metrics/MetricsBar'; +import { formatShortTime } from '@/lib/format'; + +export function SessionStats({ data }) { + const { formatMessage, labels } = useMessages(); + + return ( + <MetricsBar> + <MetricCard label={formatMessage(labels.visits)} value={data?.visits} /> + <MetricCard label={formatMessage(labels.views)} value={data?.views} /> + <MetricCard label={formatMessage(labels.events)} value={data?.events} /> + <MetricCard + label={formatMessage(labels.visitDuration)} + value={data?.totaltime / data?.visits} + formatValue={n => `${+n < 0 ? '-' : ''}${formatShortTime(Math.abs(~~n), ['m', 's'], ' ')}`} + /> + </MetricsBar> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionsDataTable.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionsDataTable.tsx new file mode 100644 index 0000000..b1b9f65 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/sessions/SessionsDataTable.tsx @@ -0,0 +1,15 @@ +import { DataGrid } from '@/components/common/DataGrid'; +import { useWebsiteSessionsQuery } from '@/components/hooks'; +import { SessionsTable } from './SessionsTable'; + +export function SessionsDataTable({ websiteId }: { websiteId?: string; teamId?: string }) { + const queryResult = useWebsiteSessionsQuery(websiteId); + + return ( + <DataGrid query={queryResult} allowPaging allowSearch> + {({ data }) => { + return <SessionsTable data={data} />; + }} + </DataGrid> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionsMetricsBar.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionsMetricsBar.tsx new file mode 100644 index 0000000..c8317a2 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/sessions/SessionsMetricsBar.tsx @@ -0,0 +1,40 @@ +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { useMessages } from '@/components/hooks'; +import { useWebsiteSessionStatsQuery } from '@/components/hooks/queries/useWebsiteSessionStatsQuery'; +import { MetricCard } from '@/components/metrics/MetricCard'; +import { MetricsBar } from '@/components/metrics/MetricsBar'; +import { formatLongNumber } from '@/lib/format'; + +export function SessionsMetricsBar({ websiteId }: { websiteId: string }) { + const { formatMessage, labels } = useMessages(); + const { data, isLoading, isFetching, error } = useWebsiteSessionStatsQuery(websiteId); + + return ( + <LoadingPanel data={data} isLoading={isLoading} isFetching={isFetching} error={error}> + {data && ( + <MetricsBar> + <MetricCard + value={data?.visitors?.value} + label={formatMessage(labels.visitors)} + formatValue={formatLongNumber} + /> + <MetricCard + value={data?.visits?.value} + label={formatMessage(labels.visits)} + formatValue={formatLongNumber} + /> + <MetricCard + value={data?.pageviews?.value} + label={formatMessage(labels.views)} + formatValue={formatLongNumber} + /> + <MetricCard + value={data?.countries?.value} + label={formatMessage(labels.countries)} + formatValue={formatLongNumber} + /> + </MetricsBar> + )} + </LoadingPanel> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionsPage.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionsPage.tsx new file mode 100644 index 0000000..8e9d2f2 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/sessions/SessionsPage.tsx @@ -0,0 +1,43 @@ +'use client'; +import { Column, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen'; +import { type Key, useState } from 'react'; +import { SessionModal } from '@/app/(main)/websites/[websiteId]/sessions/SessionModal'; +import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls'; +import { Panel } from '@/components/common/Panel'; +import { useMessages } from '@/components/hooks'; +import { getItem, setItem } from '@/lib/storage'; +import { SessionProperties } from './SessionProperties'; +import { SessionsDataTable } from './SessionsDataTable'; + +const KEY_NAME = 'umami.sessions.tab'; + +export function SessionsPage({ websiteId }) { + const [tab, setTab] = useState(getItem(KEY_NAME) || 'activity'); + const { formatMessage, labels } = useMessages(); + + const handleSelect = (value: Key) => { + setItem(KEY_NAME, value); + setTab(value); + }; + + return ( + <Column gap="3"> + <WebsiteControls websiteId={websiteId} /> + <Panel> + <Tabs selectedKey={tab} onSelectionChange={handleSelect}> + <TabList> + <Tab id="activity">{formatMessage(labels.activity)}</Tab> + <Tab id="properties">{formatMessage(labels.properties)}</Tab> + </TabList> + <TabPanel id="activity"> + <SessionsDataTable websiteId={websiteId} /> + </TabPanel> + <TabPanel id="properties"> + <SessionProperties websiteId={websiteId} /> + </TabPanel> + </Tabs> + </Panel> + <SessionModal websiteId={websiteId} /> + </Column> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionsTable.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionsTable.tsx new file mode 100644 index 0000000..5d3bb37 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/sessions/SessionsTable.tsx @@ -0,0 +1,58 @@ +import { DataColumn, DataTable, type DataTableProps } from '@umami/react-zen'; +import Link from 'next/link'; +import { Avatar } from '@/components/common/Avatar'; +import { DateDistance } from '@/components/common/DateDistance'; +import { TypeIcon } from '@/components/common/TypeIcon'; +import { useFormat, useMessages, useNavigation } from '@/components/hooks'; + +export function SessionsTable(props: DataTableProps) { + const { formatMessage, labels } = useMessages(); + const { formatValue } = useFormat(); + const { updateParams } = useNavigation(); + + return ( + <DataTable {...props}> + <DataColumn id="id" label={formatMessage(labels.session)} width="100px"> + {(row: any) => ( + <Link href={updateParams({ session: row.id })}> + <Avatar seed={row.id} size={32} /> + </Link> + )} + </DataColumn> + <DataColumn id="visits" label={formatMessage(labels.visits)} width="80px" /> + <DataColumn id="views" label={formatMessage(labels.views)} width="80px" /> + <DataColumn id="country" label={formatMessage(labels.country)}> + {(row: any) => ( + <TypeIcon type="country" value={row.country}> + {formatValue(row.country, 'country')} + </TypeIcon> + )} + </DataColumn> + <DataColumn id="city" label={formatMessage(labels.city)} /> + <DataColumn id="browser" label={formatMessage(labels.browser)}> + {(row: any) => ( + <TypeIcon type="browser" value={row.browser}> + {formatValue(row.browser, 'browser')} + </TypeIcon> + )} + </DataColumn> + <DataColumn id="os" label={formatMessage(labels.os)}> + {(row: any) => ( + <TypeIcon type="os" value={row.os}> + {formatValue(row.os, 'os')} + </TypeIcon> + )} + </DataColumn> + <DataColumn id="device" label={formatMessage(labels.device)}> + {(row: any) => ( + <TypeIcon type="device" value={row.device}> + {formatValue(row.device, 'device')} + </TypeIcon> + )} + </DataColumn> + <DataColumn id="lastAt" label={formatMessage(labels.lastSeen)}> + {(row: any) => <DateDistance date={new Date(row.createdAt)} />} + </DataColumn> + </DataTable> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/sessions/page.tsx b/src/app/(main)/websites/[websiteId]/sessions/page.tsx new file mode 100644 index 0000000..221ab71 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/sessions/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next'; +import { SessionsPage } from './SessionsPage'; + +export default async function ({ params }: { params: Promise<{ websiteId: string }> }) { + const { websiteId } = await params; + + return <SessionsPage websiteId={websiteId} />; +} + +export const metadata: Metadata = { + title: 'Sessions', +}; |